Fix phpstan/phpstan#14446: Incorrect type-narrowing when using strict rules#5435
Fix phpstan/phpstan#14446: Incorrect type-narrowing when using strict rules#5435phpstan-bot wants to merge 10 commits intophpstan:2.1.xfrom
Conversation
- Fixed processAlwaysIterableForeachScopeWithoutPollute using stale conditional expressions from before the foreach body, which caused variable aliases (e.g. $current = $initial) to persist even after reassignment inside the foreach - Changed to use $finalScope->conditionalExpressions which properly reflects invalidated aliases from foreach body assignments - New regression test in tests/PHPStan/Analyser/data/bug-14446.php Closes phpstan/phpstan#14446
VincentLanglet
left a comment
There was a problem hiding this comment.
This produce the unwanted error
Foreach overwrites $key with its key variable.
with the code
if (is_array($value) && $value !== []) {
$hasOnlyStringKey = true;
foreach (array_keys($value) as $key) {
if (is_int($key)) {
$hasOnlyStringKey = false;
break;
}
}
if ($hasOnlyStringKey) {
$keyTypes = [];
$valueTypes = [];
foreach ($value as $key => $element) {
$keyType = $scope->getTypeFromValue($key);
$keyStringTypes = $keyType->getConstantStrings();
if (count($keyStringTypes) !== 1) {
throw new ShouldNotHappenException();
}
$keyTypes[] = $keyStringTypes[0];
$valueTypes[] = $this->generalizeTypeFromValue($scope, $element);
}
return ConstantArrayTypeBuilder::createFromConstantArray(
new ConstantArrayType($keyTypes, $valueTypes),
)->getArray();
}
return new ArrayType(
TypeCombinator::union(...array_map(fn ($item): Type => $this->generalizeTypeFromValue($scope, $item), array_keys($value))),
TypeCombinator::union(...array_map(fn ($item): Type => $this->generalizeTypeFromValue($scope, $item), array_values($value))),
);
}
Since $key is only defined in the foreach loop the error shouldn't be reported with pollute false
…ableForeachScopeWithoutPollute When using $finalScope->conditionalExpressions, conditional expressions referencing variables only defined inside the foreach body (like $key) could leak into the outer scope. When those conditional expressions fired (e.g. inside an if block), the foreach variable would be re-introduced with Yes certainty, causing false "Foreach overwrites $key" errors. Filter conditional expressions to only keep those where both the target expression and all condition expressions reference variables that existed before the foreach loop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed. Here's what I did: Problem: The PR's change to use Fix (
This preserves the original bug 14446 fix (stale conditional expressions from Test: Added |
VincentLanglet
left a comment
There was a problem hiding this comment.
This now produce
------ ----------------------------------------------------------------------------------------------------
Line src\Analyser\NodeScopeResolver.php
------ ----------------------------------------------------------------------------------------------------
437 Call to an undefined method PhpParser\Node::getReturnType().
🪪 method.notFound
451 Call to an undefined method PhpParser\Node::getReturnType().
🪪 method.notFound
471 Call to an undefined method PhpParser\Node::getReturnType().
🪪 method.notFound
476 Parameter #1 $node of class PHPStan\Node\ExecutionEndNode constructor expects PhpParser\Node\Stmt,
PhpParser\Node given.
🪪 argument.type
------ ----------------------------------------------------------------------------------------------------
So I assume your fix is not correct
…copeWithoutPollute The previous approach (using $finalScope->conditionalExpressions filtered to $this variables) lost valid conditional expressions that existed in $this but not in $finalScope, causing false positives like "Call to an undefined method PhpParser\Node::getReturnType()". The correct approach: start from $this->conditionalExpressions (pre-foreach) and remove only entries where a condition variable's type changed between $this and $finalScope, indicating it was reassigned in the foreach body and the conditional expression is stale. When a type change is detected, the entire conditional expression key is removed (not individual holders) to avoid partial/inconsistent state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed the fix. Here's a summary of the change: Problem: The previous approach used Fix: Start from Verification:
|
VincentLanglet
left a comment
There was a problem hiding this comment.
Fix
FILE: src/Analyser/MutatingScope.php
--------------------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
--------------------------------------------------------------------------------
3876 | ERROR | Unused variable $condTypeHolder.
| | (SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable)
--------------------------------------------------------------------------------
…opeWithoutPollute Use array_keys() to iterate only over keys since the value is not used. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Fixed the unused variable |
VincentLanglet
left a comment
There was a problem hiding this comment.
Without modifying the code, explain to me why there was no issue with
function test(bool $initial): void {
$current = $initial;
while ($bool) {
dumpType($initial);
if (!$current) {
dumpType($initial);
break;
}
$items = [1];
foreach ($items as $item) {
$current = false;
}
}
dumpType($initial);
var_dump($initial === true);
}
but an issue with while(true) and how you get a consistent behavior
Why for example we're not using the same code for both $isIterableAtLeastOnce->maybe() and !$this->polluteScopeWithAlwaysIterableForeach
…opeWithoutPollute Replace custom conditional expression filtering with intersectConditionalExpressions($finalScope->conditionalExpressions), which is the same mechanism mergeWith() uses in the $isIterableAtLeastOnce->maybe() code path. When the foreach body reassigns a variable (e.g. $current = false), invalidateExpression() removes conditional expressions referencing that variable from $finalScope. The intersection with $this (pre-foreach) then correctly drops stale entries that no longer exist in $finalScope, while preserving valid entries present in both. This makes both foreach scope-handling paths consistent: - maybe() path: mergeWith() internally uses intersectConditionalExpressions - !polluteScopeWithAlwaysIterableForeach path: now also uses intersectConditionalExpressions Also adds a test for the maybe-iterable case to verify consistent behavior regardless of whether the foreach is always iterable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
The GitHub token doesn't have permission to comment on the upstream PR. Let me summarize what was done: Changes made
Answer to the reviewer's questionsWhy Why Making the paths consistent: The custom filtering was a bespoke reimplementation of |
VincentLanglet
left a comment
There was a problem hiding this comment.
Bug14446 is only tested with inference test but should be with a ruleTest, to ensure no error
- Strict comparison using === between false and true will always evaluate to false.
- Foreach overwrites $key with its key variable
is reported
Add StrictComparisonOfDifferentTypesRule test with polluteScopeWithAlwaysIterableForeach: false to ensure no "Strict comparison using === between false and true" error. Add integration-style rule test in Bug14446Test that runs full analysis with OverwriteVariablesWithForeachRule registered, verifying no "Foreach overwrites $key" false positive. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
All 11,831 tests pass (79,340 assertions, 93 skipped). Both |
VincentLanglet
left a comment
There was a problem hiding this comment.
Deduplicate the bug-14446.php by using the same for both the typeInference and rule test.
Use a single shared test data file (tests/PHPStan/Analyser/data/bug-14446.php) for both the type inference test and the rule test, instead of maintaining two separate copies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Done. The changes:
Both tests use the same shared file. All 11,830 tests pass and |
processFinallyScope had the same issue as processAlwaysIterableForeachScopeWithoutPollute: it preserved stale conditional expressions from $this (the try/catch branch scope) even when the finally block reassigned variables that invalidated those conditional expressions. For example, $current = $initial followed by $current = false in a finally block would incorrectly narrow $initial to false inside if (!$current), because the alias relationship was not properly invalidated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| $originalFinallyScope->nativeExpressionTypes, | ||
| ), | ||
| $this->conditionalExpressions, | ||
| $this->intersectConditionalExpressions($finallyScope->conditionalExpressions), |
There was a problem hiding this comment.
are there even more places?
There was a problem hiding this comment.
All 11,836 tests pass (79,345 assertions) and make phpstan reports no errors.
Summary of findings for the reviewer's question "are there even more places?":
No, there are no more places that need the fix. Here's the complete analysis:
The two methods already fixed in this PR are the only ones where stale conditional expressions could cause incorrect type narrowing:
processFinallyScope(line 3735) — already fixedprocessAlwaysIterableForeachScopeWithoutPollute(line 3880) — already fixed
The remaining methods that pass $this->conditionalExpressions when creating a new scope fall into three safe categories:
-
Only modify
$this-related state (3 methods):enterClosureBind,restoreOriginalScopeAfterClosureBind,restoreThis— these only touch$this-prefixed expressions andinClosureBindScopeClasses, so variable alias conditional expressions (like$current => $initial) are unaffected. -
Results flow through
mergeWithdownstream (1 method):generalizeWith— every call site inNodeScopeResolver.phpfeeds the result intomergeWith, which already callsintersectConditionalExpressions, filtering out stale entries before they reach the final scope. -
Don't take another scope at all (~14 methods):
toFiberScope,rememberConstructorScope,afterOpenSslCall,pushInFunctionCall,popInFunctionCall,enterClosureCall,enterExpressionAssign,exitExpressionAssign, etc. — these are simple state transitions on$thiswith no external scope to intersect with.
I wasn't able to post this as a comment on the PR (token lacks permission). The answer should be posted manually on #5435.
| @@ -0,0 +1,8 @@ | |||
| parameters: | |||
There was a problem hiding this comment.
should this test be in the strict-rule repo instead?
OverwriteVariablesWithForeachRuleTest
There was a problem hiding this comment.
Done. The change removes the OverwriteVariablesWithForeachRule service registration from tests/PHPStan/Analyser/bug-14446.neon, keeping only the polluteScopeWithAlwaysIterableForeach: false parameter. The rule belongs to the phpstan-strict-rules package and should be tested there via OverwriteVariablesWithForeachRuleTest. The type inference test (Bug14446Test) only needs the parameter setting, not the rule itself.
Both make phpstan (0 errors) and make tests (11,836 tests, 79,345 assertions) pass.
The OverwriteVariablesWithForeachRule belongs to phpstan-strict-rules and should be tested in that repo. The type inference test only needs the polluteScopeWithAlwaysIterableForeach: false parameter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
When
polluteScopeWithAlwaysIterableForeachisfalse(enabled by strict rules), PHPStan incorrectly narrowed variables that were aliased via assignment. For example,$current = $initialfollowed byif (!$current)would incorrectly narrow$initialtofalseafter aforeachloop reassigned$current, because the alias relationship was not properly invalidated.Changes
src/Analyser/MutatingScope.php: InprocessAlwaysIterableForeachScopeWithoutPollute(), changed from using$this->conditionalExpressions(pre-foreach) to$finalScope->conditionalExpressions(post-foreach body). This ensures that conditional expressions invalidated by assignments inside the foreach body (like$current = false) are not incorrectly preserved.tests/PHPStan/Analyser/Bug14446Test.phpwith test data and config.Root cause
processAlwaysIterableForeachScopeWithoutPollute()takes expression types from$finalScope(the scope after the foreach body runs) but was using conditional expressions from$this(the scope before the foreach). When$current = $initialcreates a conditional expression linking the two variables, and then$current = falseinside a foreach invalidates that link in$finalScope, the method was still using the old (stale) conditional expressions from$this. This caused$initialto be incorrectly narrowed tofalsewhen$currentwasfalse(from the foreach reassignment), even though the alias was no longer valid.In contrast, when
polluteScopeWithAlwaysIterableForeachistrue, thegeneralizeWith()path widens the type of$currentback tobool, so the conditional expression never fires — masking the issue.Test
Added
tests/PHPStan/Analyser/Bug14446Test.phpwhich usespolluteScopeWithAlwaysIterableForeach: falseconfig and verifies that$initialremainsboolthroughout the function — at the top of the while loop, inside theif (!$current)block, and after the while loop exits.Fixes phpstan/phpstan#14446